mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-18 12:43:57 +00:00
New structure
This commit is contained in:
25
src/util/notifications/AdaptiveThrottler.ts
Normal file
25
src/util/notifications/AdaptiveThrottler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class AdaptiveThrottler {
|
||||
private errorCount = 0
|
||||
private successCount = 0
|
||||
private window: Array<{ ok: boolean; at: number }> = []
|
||||
private readonly maxWindow = 50
|
||||
|
||||
record(ok: boolean) {
|
||||
this.window.push({ ok, at: Date.now() })
|
||||
if (ok) this.successCount++
|
||||
else this.errorCount++
|
||||
if (this.window.length > this.maxWindow) {
|
||||
const removed = this.window.shift()
|
||||
if (removed) removed.ok ? this.successCount-- : this.errorCount--
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a multiplier to apply to waits (1 = normal). */
|
||||
getDelayMultiplier(): number {
|
||||
const total = Math.max(1, this.successCount + this.errorCount)
|
||||
const errRatio = this.errorCount / total
|
||||
// 0% errors -> 1x; 50% errors -> ~1.8x; 80% -> ~2.5x (cap)
|
||||
const mult = 1 + Math.min(1.5, errRatio * 2)
|
||||
return Number(mult.toFixed(2))
|
||||
}
|
||||
}
|
||||
113
src/util/notifications/ConclusionWebhook.ts
Normal file
113
src/util/notifications/ConclusionWebhook.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import axios from 'axios'
|
||||
import { DISCORD } from '../../constants'
|
||||
import { Config } from '../../interface/Config'
|
||||
import { log } from './Logger'
|
||||
import { Ntfy } from './Ntfy'
|
||||
|
||||
interface DiscordField {
|
||||
name: string
|
||||
value: string
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
interface DiscordEmbed {
|
||||
title?: string
|
||||
description?: string
|
||||
color?: number
|
||||
fields?: DiscordField[]
|
||||
timestamp?: string
|
||||
thumbnail?: {
|
||||
url: string
|
||||
}
|
||||
footer?: {
|
||||
text: string
|
||||
icon_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface WebhookPayload {
|
||||
username: string
|
||||
avatar_url: string
|
||||
embeds: DiscordEmbed[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a clean, structured Discord webhook notification
|
||||
*/
|
||||
export async function ConclusionWebhook(
|
||||
config: Config,
|
||||
title: string,
|
||||
description: string,
|
||||
fields?: DiscordField[],
|
||||
color?: number
|
||||
) {
|
||||
const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url
|
||||
const hasWebhook = config.webhook?.enabled && config.webhook.url
|
||||
|
||||
if (!hasConclusion && !hasWebhook) return
|
||||
|
||||
const embed: DiscordEmbed = {
|
||||
title,
|
||||
description,
|
||||
color: color || 0x0078D4,
|
||||
timestamp: new Date().toISOString(),
|
||||
thumbnail: {
|
||||
url: DISCORD.AVATAR_URL
|
||||
}
|
||||
}
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
embed.fields = fields
|
||||
}
|
||||
|
||||
const payload: WebhookPayload = {
|
||||
username: DISCORD.WEBHOOK_USERNAME,
|
||||
avatar_url: DISCORD.AVATAR_URL,
|
||||
embeds: [embed]
|
||||
}
|
||||
|
||||
const postWebhook = async (url: string, label: string) => {
|
||||
const maxAttempts = 3
|
||||
let lastError: unknown = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await axios.post(url, payload, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 15000
|
||||
})
|
||||
log('main', 'WEBHOOK', `${label} notification sent successfully (attempt ${attempt})`)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < maxAttempts) {
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
const delayMs = 1000 * Math.pow(2, attempt - 1)
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error')
|
||||
}
|
||||
|
||||
const urls = new Set<string>()
|
||||
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
|
||||
if (hasWebhook) urls.add(config.webhook!.url)
|
||||
|
||||
await Promise.all(
|
||||
Array.from(urls).map((url, index) => postWebhook(url, `webhook-${index + 1}`))
|
||||
)
|
||||
|
||||
// Optional NTFY notification
|
||||
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
|
||||
const message = `${title}\n${description}${fields ? '\n\n' + fields.map(f => `${f.name}: ${f.value}`).join('\n') : ''}`
|
||||
const ntfyType = color === 0xFF0000 ? 'error' : color === 0xFFAA00 ? 'warn' : 'log'
|
||||
|
||||
try {
|
||||
await Ntfy(message, ntfyType)
|
||||
log('main', 'NTFY', 'Notification sent successfully')
|
||||
} catch (error) {
|
||||
log('main', 'NTFY', `Failed to send notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
245
src/util/notifications/ErrorReportingWebhook.ts
Normal file
245
src/util/notifications/ErrorReportingWebhook.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import axios from 'axios'
|
||||
import { DISCORD } from '../../constants'
|
||||
import { Config } from '../../interface/Config'
|
||||
|
||||
interface ErrorReportPayload {
|
||||
error: string
|
||||
stack?: string
|
||||
context: {
|
||||
version: string
|
||||
platform: string
|
||||
arch: string
|
||||
nodeVersion: string
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple obfuscation/deobfuscation for webhook URL
|
||||
* Not for security, just to avoid easy scraping
|
||||
*/
|
||||
export function obfuscateWebhookUrl(url: string): string {
|
||||
return Buffer.from(url).toString('base64')
|
||||
}
|
||||
|
||||
export function deobfuscateWebhookUrl(encoded: string): string {
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64').toString('utf-8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should be reported (filter false positives and user configuration errors)
|
||||
*/
|
||||
function shouldReportError(errorMessage: string): boolean {
|
||||
const lowerMessage = errorMessage.toLowerCase()
|
||||
|
||||
// List of patterns that indicate user configuration errors (not reportable bugs)
|
||||
const userConfigPatterns = [
|
||||
/accounts\.jsonc.*not found/i,
|
||||
/config\.jsonc.*not found/i,
|
||||
/invalid.*credentials/i,
|
||||
/login.*failed/i,
|
||||
/authentication.*failed/i,
|
||||
/proxy.*connection.*failed/i,
|
||||
/totp.*invalid/i,
|
||||
/2fa.*failed/i,
|
||||
/incorrect.*password/i,
|
||||
/account.*suspended/i,
|
||||
/account.*banned/i,
|
||||
/no.*accounts.*enabled/i,
|
||||
/invalid.*configuration/i,
|
||||
/missing.*required.*field/i,
|
||||
/port.*already.*in.*use/i,
|
||||
/eaddrinuse/i,
|
||||
// Rebrowser-playwright expected errors (benign, non-fatal)
|
||||
/rebrowser-patches.*cannot get world/i,
|
||||
/session closed.*rebrowser/i,
|
||||
/addScriptToEvaluateOnNewDocument.*session closed/i
|
||||
]
|
||||
|
||||
// Don't report user configuration errors
|
||||
for (const pattern of userConfigPatterns) {
|
||||
if (pattern.test(lowerMessage)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// List of patterns that indicate expected/handled errors (not bugs)
|
||||
const expectedErrorPatterns = [
|
||||
/no.*points.*to.*earn/i,
|
||||
/already.*completed/i,
|
||||
/activity.*not.*available/i,
|
||||
/daily.*limit.*reached/i,
|
||||
/quest.*not.*found/i,
|
||||
/promotion.*expired/i
|
||||
]
|
||||
|
||||
// Don't report expected/handled errors
|
||||
for (const pattern of expectedErrorPatterns) {
|
||||
if (pattern.test(lowerMessage)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Report everything else (genuine bugs)
|
||||
return true
|
||||
}
|
||||
|
||||
// Hardcoded webhook URL for error reporting (obfuscated)
|
||||
const ERROR_WEBHOOK_URL = 'aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQzNzExMTk2MjM5NDY4OTYyOS90bHZHS1phSDktckppcjR0blpLU1pwUkhTM1liZU40dlpudUN2NTBrNU1wQURZUlBuSG5aNk15YkFsZ0Y1UUZvNktIXw=='
|
||||
|
||||
/**
|
||||
* Send error report to Discord webhook for community contribution
|
||||
* Only sends non-sensitive error information to help improve the project
|
||||
*/
|
||||
export async function sendErrorReport(
|
||||
config: Config,
|
||||
error: Error | string,
|
||||
additionalContext?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
// Check if error reporting is enabled
|
||||
if (!config.errorReporting?.enabled) return
|
||||
|
||||
try {
|
||||
// Deobfuscate webhook URL
|
||||
const webhookUrl = deobfuscateWebhookUrl(ERROR_WEBHOOK_URL)
|
||||
if (!webhookUrl || !webhookUrl.startsWith('https://discord.com/api/webhooks/')) {
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Filter out false positives and user configuration errors
|
||||
if (!shouldReportError(errorMessage)) {
|
||||
return
|
||||
}
|
||||
const errorStack = error instanceof Error ? error.stack : undefined
|
||||
|
||||
// Sanitize error message and stack - remove any potential sensitive data
|
||||
const sanitize = (text: string): string => {
|
||||
return text
|
||||
// Remove email addresses
|
||||
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[EMAIL_REDACTED]')
|
||||
// Remove absolute paths (Windows and Unix)
|
||||
.replace(/[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*/g, '[PATH_REDACTED]')
|
||||
.replace(/\/(?:home|Users)\/[^/\s]+(?:\/[^/\s]+)*/g, '[PATH_REDACTED]')
|
||||
// Remove IP addresses
|
||||
.replace(/\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g, '[IP_REDACTED]')
|
||||
// Remove potential tokens/keys (sequences of 20+ alphanumeric chars)
|
||||
.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[TOKEN_REDACTED]')
|
||||
}
|
||||
|
||||
const sanitizedMessage = sanitize(errorMessage)
|
||||
const sanitizedStack = errorStack ? sanitize(errorStack).split('\n').slice(0, 10).join('\n') : undefined
|
||||
|
||||
// Build context payload with system information
|
||||
const payload: ErrorReportPayload = {
|
||||
error: sanitizedMessage,
|
||||
stack: sanitizedStack,
|
||||
context: {
|
||||
version: getProjectVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
nodeVersion: process.version,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional context if provided (also sanitized)
|
||||
if (additionalContext) {
|
||||
const sanitizedContext: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(additionalContext)) {
|
||||
if (typeof value === 'string') {
|
||||
sanitizedContext[key] = sanitize(value)
|
||||
} else {
|
||||
sanitizedContext[key] = value
|
||||
}
|
||||
}
|
||||
Object.assign(payload.context, sanitizedContext)
|
||||
}
|
||||
|
||||
// Build Discord embed
|
||||
const embed = {
|
||||
title: '🐛 Automatic Error Report',
|
||||
description: `\`\`\`\n${sanitizedMessage.slice(0, 500)}\n\`\`\``,
|
||||
color: DISCORD.COLOR_RED,
|
||||
fields: [
|
||||
{
|
||||
name: '📦 Version',
|
||||
value: payload.context.version,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '💻 Platform',
|
||||
value: `${payload.context.platform} (${payload.context.arch})`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '⚙️ Node.js',
|
||||
value: payload.context.nodeVersion,
|
||||
inline: true
|
||||
}
|
||||
],
|
||||
timestamp: payload.context.timestamp,
|
||||
footer: {
|
||||
text: 'Automatic error reporting - Thank you for contributing!',
|
||||
icon_url: DISCORD.AVATAR_URL
|
||||
}
|
||||
}
|
||||
|
||||
// Add stack trace field if available (truncated)
|
||||
if (sanitizedStack) {
|
||||
embed.fields.push({
|
||||
name: '📋 Stack Trace (truncated)',
|
||||
value: `\`\`\`\n${sanitizedStack.slice(0, 800)}\n\`\`\``,
|
||||
inline: false
|
||||
})
|
||||
}
|
||||
|
||||
// Add additional context fields if provided
|
||||
if (additionalContext) {
|
||||
for (const [key, value] of Object.entries(additionalContext)) {
|
||||
if (embed.fields.length < 25) { // Discord limit
|
||||
embed.fields.push({
|
||||
name: key,
|
||||
value: String(value).slice(0, 1024),
|
||||
inline: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const discordPayload = {
|
||||
username: 'Microsoft-Rewards-Bot Error Reporter',
|
||||
avatar_url: DISCORD.AVATAR_URL,
|
||||
embeds: [embed]
|
||||
}
|
||||
|
||||
// Send to webhook with timeout
|
||||
await axios.post(webhookUrl, discordPayload, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 10000
|
||||
})
|
||||
} catch (webhookError) {
|
||||
// Silent fail - we don't want error reporting to break the application
|
||||
// Only log to stderr to avoid recursion
|
||||
process.stderr.write(`[ErrorReporting] Failed to send error report: ${webhookError}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project version from package.json
|
||||
*/
|
||||
function getProjectVersion(): string {
|
||||
try {
|
||||
// Dynamic import for package.json
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const packageJson = require('../../package.json') as { version?: string }
|
||||
return packageJson.version || 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
352
src/util/notifications/Logger.ts
Normal file
352
src/util/notifications/Logger.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import axios from 'axios'
|
||||
import chalk from 'chalk'
|
||||
import { DISCORD, LOGGER_CLEANUP } from '../../constants'
|
||||
import { loadConfig } from '../state/Load'
|
||||
import { sendErrorReport } from './ErrorReportingWebhook'
|
||||
import { Ntfy } from './Ntfy'
|
||||
|
||||
/**
|
||||
* Safe error logger for catch blocks
|
||||
* Use in .catch() to log errors without breaking flow
|
||||
* @example await action().catch(logError('ACTION', 'Failed to do something'))
|
||||
*/
|
||||
export function logError(title: string, message: string, isMobile: boolean | 'main' = 'main') {
|
||||
return (error: unknown) => {
|
||||
const errMsg = error instanceof Error ? error.message : String(error)
|
||||
log(isMobile, title, `${message}: ${errMsg}`, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
type WebhookBuffer = {
|
||||
lines: string[]
|
||||
sending: boolean
|
||||
timer?: NodeJS.Timeout
|
||||
}
|
||||
|
||||
const webhookBuffers = new Map<string, WebhookBuffer>()
|
||||
|
||||
// Periodic cleanup of old/idle webhook buffers to prevent memory leaks
|
||||
// IMPROVED: Using centralized constants from constants.ts
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
|
||||
for (const [url, buf] of webhookBuffers.entries()) {
|
||||
if (!buf.sending && buf.lines.length === 0) {
|
||||
const lastActivity = (buf as unknown as { lastActivity?: number }).lastActivity || 0
|
||||
if (now - lastActivity > LOGGER_CLEANUP.BUFFER_MAX_AGE_MS) {
|
||||
webhookBuffers.delete(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, LOGGER_CLEANUP.BUFFER_CLEANUP_INTERVAL_MS)
|
||||
|
||||
// FIXED: Allow cleanup to be stopped with proper fallback
|
||||
// unref() prevents process from hanging but may not exist in all environments
|
||||
if (typeof cleanupInterval.unref === 'function') {
|
||||
cleanupInterval.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the webhook buffer cleanup interval
|
||||
* Call this during graceful shutdown to prevent memory leaks
|
||||
*/
|
||||
export function stopWebhookCleanup(): void {
|
||||
clearInterval(cleanupInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a webhook buffer for the given URL
|
||||
* Buffers batch log messages to reduce Discord API calls
|
||||
*/
|
||||
function getBuffer(url: string): WebhookBuffer {
|
||||
let buf = webhookBuffers.get(url)
|
||||
if (!buf) {
|
||||
buf = { lines: [], sending: false }
|
||||
webhookBuffers.set(url, buf)
|
||||
}
|
||||
// Track last activity for cleanup
|
||||
(buf as unknown as { lastActivity: number }).lastActivity = Date.now()
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Send batched log messages to Discord webhook
|
||||
* Handles rate limiting and message size constraints
|
||||
*/
|
||||
async function sendBatch(url: string, buf: WebhookBuffer): Promise<void> {
|
||||
if (buf.sending) return
|
||||
buf.sending = true
|
||||
while (buf.lines.length > 0) {
|
||||
const chunk: string[] = []
|
||||
let currentLength = 0
|
||||
while (buf.lines.length > 0) {
|
||||
const next = buf.lines[0]!
|
||||
const projected = currentLength + next.length + (chunk.length > 0 ? 1 : 0)
|
||||
if (projected > DISCORD.MAX_EMBED_LENGTH && chunk.length > 0) break
|
||||
buf.lines.shift()
|
||||
chunk.push(next)
|
||||
currentLength = projected
|
||||
}
|
||||
|
||||
const content = chunk.join('\n').slice(0, DISCORD.MAX_EMBED_LENGTH)
|
||||
if (!content) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Enhanced webhook payload with embed, username and avatar
|
||||
const payload = {
|
||||
username: DISCORD.WEBHOOK_USERNAME,
|
||||
avatar_url: DISCORD.AVATAR_URL,
|
||||
embeds: [{
|
||||
description: `\`\`\`\n${content}\n\`\`\``,
|
||||
color: determineColorFromContent(content),
|
||||
timestamp: new Date().toISOString()
|
||||
}]
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' }, timeout: DISCORD.WEBHOOK_TIMEOUT })
|
||||
await new Promise(resolve => setTimeout(resolve, DISCORD.RATE_LIMIT_DELAY))
|
||||
} catch (error) {
|
||||
// Re-queue failed batch at front and exit loop
|
||||
buf.lines = chunk.concat(buf.lines)
|
||||
// Note: Using stderr directly here to avoid circular dependency with log()
|
||||
// This is an internal logger error that shouldn't go through the logging system
|
||||
process.stderr.write(`[Webhook] live log delivery failed: ${error}\n`)
|
||||
break
|
||||
}
|
||||
}
|
||||
buf.sending = false
|
||||
}
|
||||
|
||||
// IMPROVED: Extracted color determination logic for better maintainability
|
||||
type ColorRule = { pattern: RegExp | string; color: number }
|
||||
const COLOR_RULES: ColorRule[] = [
|
||||
{ pattern: /\[banned\]|\[security\]|suspended|compromised/i, color: DISCORD.COLOR_RED },
|
||||
{ pattern: /\[error\]|✗/i, color: DISCORD.COLOR_CRIMSON },
|
||||
{ pattern: /\[warn\]|⚠/i, color: DISCORD.COLOR_ORANGE },
|
||||
{ pattern: /\[ok\]|✓|complet/i, color: DISCORD.COLOR_GREEN },
|
||||
{ pattern: /\[main\]/i, color: DISCORD.COLOR_BLUE }
|
||||
]
|
||||
|
||||
function determineColorFromContent(content: string): number {
|
||||
const lower = content.toLowerCase()
|
||||
|
||||
// Check rules in priority order
|
||||
for (const rule of COLOR_RULES) {
|
||||
if (typeof rule.pattern === 'string') {
|
||||
if (lower.includes(rule.pattern)) return rule.color
|
||||
} else {
|
||||
if (rule.pattern.test(lower)) return rule.color
|
||||
}
|
||||
}
|
||||
|
||||
return DISCORD.COLOR_GRAY
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if config has valid logging configuration
|
||||
* IMPROVED: Enhanced edge case handling and null checks
|
||||
*/
|
||||
function hasValidLogging(config: unknown): config is { logging: { excludeFunc?: string[]; webhookExcludeFunc?: string[]; redactEmails?: boolean; liveWebhookUrl?: string } } {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!('logging' in config)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cfg = config as Record<string, unknown>
|
||||
const logging = cfg.logging
|
||||
|
||||
if (typeof logging !== 'object' || logging === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate optional fields have correct types if present
|
||||
const loggingObj = logging as Record<string, unknown>
|
||||
|
||||
if ('excludeFunc' in loggingObj && !Array.isArray(loggingObj.excludeFunc)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ('webhookExcludeFunc' in loggingObj && !Array.isArray(loggingObj.webhookExcludeFunc)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ('redactEmails' in loggingObj && typeof loggingObj.redactEmails !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
|
||||
if ('liveWebhookUrl' in loggingObj && typeof loggingObj.liveWebhookUrl !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function enqueueWebhookLog(url: string, line: string) {
|
||||
const buf = getBuffer(url)
|
||||
buf.lines.push(line)
|
||||
if (!buf.timer) {
|
||||
buf.timer = setTimeout(() => {
|
||||
buf.timer = undefined
|
||||
void sendBatch(url, buf)
|
||||
}, DISCORD.DEBOUNCE_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized logging function with console, Discord webhook, and NTFY support
|
||||
* @param isMobile - Platform identifier ('main', true for mobile, false for desktop)
|
||||
* @param title - Log title/category (e.g., 'LOGIN', 'SEARCH')
|
||||
* @param message - Log message content
|
||||
* @param type - Log level (log, warn, error)
|
||||
* @param color - Optional chalk color override
|
||||
* @returns Error object if type is 'error' (allows `throw log(...)`)
|
||||
* @example log('main', 'STARTUP', 'Bot started', 'log')
|
||||
* @example throw log(false, 'LOGIN', 'Auth failed', 'error')
|
||||
*/
|
||||
export function log(isMobile: boolean | 'main', title: string, message: string, type: 'log' | 'warn' | 'error' = 'log', color?: keyof typeof chalk): Error | void {
|
||||
const configData = loadConfig()
|
||||
|
||||
// Access logging config with type guard for safer access
|
||||
const logging = hasValidLogging(configData) ? configData.logging : undefined
|
||||
const logExcludeFunc = logging?.excludeFunc ?? (configData as { logExcludeFunc?: string[] }).logExcludeFunc ?? []
|
||||
|
||||
if (logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentTime = new Date().toLocaleString()
|
||||
const platformText = isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP'
|
||||
|
||||
// Clean string for notifications (no chalk, structured)
|
||||
type LoggingCfg = { excludeFunc?: string[]; webhookExcludeFunc?: string[]; redactEmails?: boolean }
|
||||
const loggingCfg: LoggingCfg = logging || {}
|
||||
const shouldRedact = !!loggingCfg.redactEmails
|
||||
const redact = (s: string) => shouldRedact ? s.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig, (m) => {
|
||||
const [u, d] = m.split('@'); return `${(u || '').slice(0, 2)}***@${d || ''}`
|
||||
}) : s
|
||||
const cleanStr = redact(`[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${platformText} [${title}] ${message}`)
|
||||
|
||||
// Define conditions for sending to NTFY
|
||||
const ntfyConditions = {
|
||||
log: [
|
||||
message.toLowerCase().includes('started tasks for account'),
|
||||
message.toLowerCase().includes('press the number'),
|
||||
message.toLowerCase().includes('no points to earn')
|
||||
],
|
||||
error: [],
|
||||
warn: [
|
||||
message.toLowerCase().includes('aborting'),
|
||||
message.toLowerCase().includes('didn\'t gain')
|
||||
]
|
||||
}
|
||||
|
||||
// Check if the current log type and message meet the NTFY conditions
|
||||
try {
|
||||
if (type in ntfyConditions && ntfyConditions[type as keyof typeof ntfyConditions].some(condition => condition)) {
|
||||
// Fire-and-forget
|
||||
Promise.resolve(Ntfy(cleanStr, type)).catch(() => { /* ignore ntfy errors */ })
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Console output with better formatting and contextual icons
|
||||
const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '✓'
|
||||
const platformColor = isMobile === 'main' ? chalk.cyan : isMobile ? chalk.blue : chalk.magenta
|
||||
const typeColor = type === 'error' ? chalk.red : type === 'warn' ? chalk.yellow : chalk.green
|
||||
|
||||
// Add contextual icon based on title/message (ASCII-safe for Windows PowerShell)
|
||||
const titleLower = title.toLowerCase()
|
||||
const msgLower = message.toLowerCase()
|
||||
|
||||
// ASCII-safe icons for Windows PowerShell compatibility
|
||||
const iconMap: Array<[RegExp, string]> = [
|
||||
[/security|compromised/i, '[SECURITY]'],
|
||||
[/ban|suspend/i, '[BANNED]'],
|
||||
[/error/i, '[ERROR]'],
|
||||
[/warn/i, '[WARN]'],
|
||||
[/success|complet/i, '[OK]'],
|
||||
[/login/i, '[LOGIN]'],
|
||||
[/point/i, '[POINTS]'],
|
||||
[/search/i, '[SEARCH]'],
|
||||
[/activity|quiz|poll/i, '[ACTIVITY]'],
|
||||
[/browser/i, '[BROWSER]'],
|
||||
[/main/i, '[MAIN]']
|
||||
]
|
||||
|
||||
let icon = ''
|
||||
for (const [pattern, symbol] of iconMap) {
|
||||
if (pattern.test(titleLower) || pattern.test(msgLower)) {
|
||||
icon = chalk.dim(symbol)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const iconPart = icon ? icon + ' ' : ''
|
||||
|
||||
const formattedStr = [
|
||||
chalk.gray(`[${currentTime}]`),
|
||||
chalk.gray(`[${process.pid}]`),
|
||||
typeColor(`${typeIndicator}`),
|
||||
platformColor(`[${platformText}]`),
|
||||
chalk.bold(`[${title}]`),
|
||||
iconPart + redact(message)
|
||||
].join(' ')
|
||||
|
||||
const applyChalk = color && typeof chalk[color] === 'function' ? chalk[color] as (msg: string) => string : null
|
||||
|
||||
// Log based on the type
|
||||
switch (type) {
|
||||
case 'warn':
|
||||
applyChalk ? console.warn(applyChalk(formattedStr)) : console.warn(formattedStr)
|
||||
break
|
||||
|
||||
case 'error':
|
||||
applyChalk ? console.error(applyChalk(formattedStr)) : console.error(formattedStr)
|
||||
break
|
||||
|
||||
default:
|
||||
applyChalk ? console.log(applyChalk(formattedStr)) : console.log(formattedStr)
|
||||
break
|
||||
}
|
||||
|
||||
// Webhook streaming (live logs)
|
||||
try {
|
||||
const loggingCfg: Record<string, unknown> = (logging || {}) as Record<string, unknown>
|
||||
const webhookCfg = configData.webhook
|
||||
const liveUrlRaw = typeof loggingCfg.liveWebhookUrl === 'string' ? loggingCfg.liveWebhookUrl.trim() : ''
|
||||
const liveUrl = liveUrlRaw || (webhookCfg?.enabled && webhookCfg.url ? webhookCfg.url : '')
|
||||
const webhookExclude = Array.isArray(loggingCfg.webhookExcludeFunc) ? loggingCfg.webhookExcludeFunc : configData.webhookLogExcludeFunc || []
|
||||
const webhookExcluded = Array.isArray(webhookExclude) && webhookExclude.some((x: string) => x.toLowerCase() === title.toLowerCase())
|
||||
if (liveUrl && !webhookExcluded) {
|
||||
enqueueWebhookLog(liveUrl, cleanStr)
|
||||
}
|
||||
} catch (error) {
|
||||
// Note: Using stderr directly to avoid recursion - this is an internal logger error
|
||||
process.stderr.write(`[Logger] Failed to enqueue webhook log: ${error}\n`)
|
||||
}
|
||||
|
||||
// Automatic error reporting to community webhook (fire and forget)
|
||||
if (type === 'error') {
|
||||
const errorObj = new Error(cleanStr)
|
||||
|
||||
// Send error report asynchronously without blocking
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
await sendErrorReport(configData, errorObj, {
|
||||
title,
|
||||
platform: platformText
|
||||
})
|
||||
} catch {
|
||||
// Silent fail - error reporting should never break the application
|
||||
}
|
||||
}).catch(() => {
|
||||
// Catch any promise rejection silently
|
||||
})
|
||||
|
||||
return errorObj
|
||||
}
|
||||
}
|
||||
27
src/util/notifications/Ntfy.ts
Normal file
27
src/util/notifications/Ntfy.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import axios from 'axios'
|
||||
import { loadConfig } from '../state/Load'
|
||||
|
||||
const NOTIFICATION_TYPES = {
|
||||
error: { priority: 'max', tags: 'rotating_light' }, // Customize the ERROR icon here, see: https://docs.ntfy.sh/emojis/
|
||||
warn: { priority: 'high', tags: 'warning' }, // Customize the WARN icon here, see: https://docs.ntfy.sh/emojis/
|
||||
log: { priority: 'default', tags: 'medal_sports' } // Customize the LOG icon here, see: https://docs.ntfy.sh/emojis/
|
||||
}
|
||||
|
||||
export async function Ntfy(message: string, type: keyof typeof NOTIFICATION_TYPES = 'log'): Promise<void> {
|
||||
const config = loadConfig().ntfy
|
||||
if (!config?.enabled || !config.url || !config.topic) return
|
||||
|
||||
try {
|
||||
const { priority, tags } = NOTIFICATION_TYPES[type]
|
||||
const headers = {
|
||||
Title: 'Microsoft Rewards Script',
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
...(config.authToken && { Authorization: `Bearer ${config.authToken}` })
|
||||
}
|
||||
|
||||
await axios.post(`${config.url}/${config.topic}`, message, { headers })
|
||||
} catch (error) {
|
||||
// Silently fail - NTFY is a non-critical notification service
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user