diff --git a/jest.setup.ts b/jest.setup.ts index f24dd89..ce7fc44 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,25 +1,38 @@ // Make this a module by adding an export export {}; -const mockFetch = jest.fn(async () => ({ - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - redirected: false, - type: 'basic' as ResponseType, - url: 'https://mock.url', - json: async () => ({}), - text: async () => '', - clone: () => ({} as Response), - body: null, - bodyUsed: false, - arrayBuffer: async () => new ArrayBuffer(0), - blob: async () => new Blob(), - formData: async () => new FormData(), -})) as jest.Mock>; +class MockResponse implements Response { + readonly headers: Headers; + readonly ok: boolean; + readonly redirected: boolean; + readonly status: number; + readonly statusText: string; + readonly type: "basic" | "cors" | "default" | "error" | "opaque" | "opaqueredirect"; + readonly url: string; + readonly body: ReadableStream | null; + readonly bodyUsed: boolean; -global.fetch = mockFetch; + constructor(private data: any = {}) { + this.headers = new Headers(); + this.ok = true; + this.redirected = false; + this.status = 200; + this.statusText = 'OK'; + this.type = 'basic'; + this.url = ''; + this.body = null; + this.bodyUsed = false; + } + + json() { return Promise.resolve(this.data); } + text() { return Promise.resolve(''); } + blob() { return Promise.resolve(new Blob([])); } + arrayBuffer() { return Promise.resolve(new ArrayBuffer(0)); } + formData() { return Promise.resolve(new FormData()); } + clone(): Response { return new MockResponse(this.data); } +} + +(global.fetch as any) = jest.fn(() => Promise.resolve(new MockResponse())); // Reset all mocks before each test beforeEach(() => { diff --git a/package.json b/package.json index fcf3e5a..6964c54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "0xalice-tgram-bot", - "version": "0.2.11", + "version": "0.2.12", "description": "Batched Telegram notification bot for 0xAlice", "type": "module", "main": "./dist/cjs/index.js", diff --git a/src/__tests__/telegram.test.ts b/src/__tests__/processors/telegram.test.ts similarity index 83% rename from src/__tests__/telegram.test.ts rename to src/__tests__/processors/telegram.test.ts index e6037a1..5122a56 100644 --- a/src/__tests__/telegram.test.ts +++ b/src/__tests__/processors/telegram.test.ts @@ -1,5 +1,5 @@ -import { createTelegramProcessor } from '../processors/telegram'; -import type { Message, TelegramConfig } from '../types'; +import { createTelegramProcessor } from '../../processors/telegram'; +import type { Message, TelegramConfig } from '../../types'; describe('TelegramProcessor', () => { const defaultConfig: TelegramConfig = { @@ -8,9 +8,14 @@ describe('TelegramProcessor', () => { }; beforeEach(() => { - // Mock the native fetch - (global.fetch as jest.Mock) = jest.fn(() => - Promise.resolve({ ok: true }) + // Updated mock implementation + (global.fetch as jest.Mock).mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve({}) + } as Response) ); }); @@ -55,11 +60,14 @@ describe('TelegramProcessor', () => { }); it('should throw error on failed API response', async () => { + // Updated mock implementation for failed response (global.fetch as jest.Mock).mockImplementationOnce(() => Promise.resolve({ ok: false, - statusText: 'Bad Request' - }) + status: 400, + statusText: 'Bad Request', + json: () => Promise.resolve({}) + } as Response) ); const processor = createTelegramProcessor(defaultConfig); diff --git a/src/processors/telegram.ts b/src/processors/telegram.ts index 1fcef3b..14c8e16 100644 --- a/src/processors/telegram.ts +++ b/src/processors/telegram.ts @@ -1,7 +1,10 @@ import type { Message, MessageProcessor, TelegramConfig } from '../types'; +import { classifyError } from '../utils/errorClassifier'; // import fetch from 'node-fetch'; -export function createTelegramProcessor(config: TelegramConfig): MessageProcessor { +export function createTelegramProcessor( + config: TelegramConfig +): MessageProcessor { const { botToken, chatId, development = false } = config; const baseUrl = `https://api.telegram.org/bot${botToken}`; @@ -10,13 +13,40 @@ export function createTelegramProcessor(config: TelegramConfig): MessageProcesso console.log('[Telegram] Would send messages:', messages); return; } - + if (!messages.length) { return; } - const text = messages - .map((msg) => `[${msg.level.toUpperCase()}] ${msg.text}`) + const formattedMessages = messages + .map((msg) => { + const prefix = msg.level.toUpperCase(); + let text = `[${prefix}] ${msg.text}`; + + if (msg.level === 'error' && msg.error) { + const classified = classifyError(msg.error); + + // Skip throttled errors + if (classified.shouldThrottle) { + if (classified.nextAllowedTimestamp) { + const waitMinutes = Math.ceil( + (classified.nextAllowedTimestamp - Date.now()) / 60000 + ); + text += `\n[THROTTLED] Similar errors suppressed for ${waitMinutes} minutes`; + } + return null; + } + + text += `\nCategory: ${classified.category}`; + text += `\nSeverity: ${classified.severity}`; + if (classified.details) { + text += `\nDetails: ${JSON.stringify(classified.details)}`; + } + } + + return text; + }) + .filter(Boolean) // Remove null entries from throttled errors .join('\n'); const response = await fetch(`${baseUrl}/sendMessage`, { @@ -24,7 +54,7 @@ export function createTelegramProcessor(config: TelegramConfig): MessageProcesso headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, - text, + text: formattedMessages, parse_mode: 'HTML', }), }); diff --git a/src/types.ts b/src/types.ts index 0c8f637..83551c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export type Message = { chatId: string; text: string; level: NotificationLevel; + error?: Error | string; }; export type BatcherConfig = { diff --git a/src/utils/errorClassifier.ts b/src/utils/errorClassifier.ts new file mode 100644 index 0000000..f24cedd --- /dev/null +++ b/src/utils/errorClassifier.ts @@ -0,0 +1,141 @@ +type ErrorPattern = { + pattern: RegExp; + category: string; + severity: 'low' | 'medium' | 'high'; + backpressure?: { + windowMs: number; + maxErrors: number; + cooldownMs: number; + }; +}; + +// Default patterns +const DEFAULT_ERROR_PATTERNS: ErrorPattern[] = [ + { + pattern: /duplicate key value violates unique constraint/i, + category: 'DATABASE_CONSTRAINT_VIOLATION', + severity: 'medium', + backpressure: { + windowMs: 60000, + maxErrors: 5, + cooldownMs: 300000 + } + }, + { + pattern: /connection refused|connection timeout/i, + category: 'CONNECTION_ERROR', + severity: 'high', + backpressure: { + windowMs: 30000, + maxErrors: 3, + cooldownMs: 60000 + } + }, + { + pattern: /invalid signature|unauthorized/i, + category: 'AUTH_ERROR', + severity: 'high' + } +]; + +// Store custom patterns +let customPatterns: ErrorPattern[] = []; + +export function addErrorPatterns(patterns: ErrorPattern[]): void { + customPatterns = customPatterns.concat(patterns); +} + +export function resetErrorPatterns(): void { + customPatterns = []; +} + +// Get all patterns (custom patterns take precedence) +function getPatterns(): ErrorPattern[] { + return [...customPatterns, ...DEFAULT_ERROR_PATTERNS]; +} + +// Track error occurrences +const errorTracker = new Map(); + +type ClassifiedError = { + originalMessage: string; + category: string; + severity: 'low' | 'medium' | 'high'; + details?: Record; + shouldThrottle: boolean; + nextAllowedTimestamp?: number; +}; + +export function classifyError(error: Error | string): ClassifiedError { + const message = error instanceof Error ? error.message : error; + const now = Date.now(); + + for (const { pattern, category, severity, backpressure } of getPatterns()) { + if (pattern.test(message)) { + const details: Record = {}; + + // Extract specific details based on category + if (category === 'DATABASE_CONSTRAINT_VIOLATION') { + const constraint = message.match(/constraint "([^"]+)"/)?.[1]; + if (constraint) { + details.constraint = constraint; + } + } + + // Handle backpressure if configured + let shouldThrottle = false; + let nextAllowedTimestamp: number | undefined; + + if (backpressure) { + const tracker = errorTracker.get(category) || { timestamps: [] }; + + // Check if in cooldown + if (tracker.cooldownUntil && now < tracker.cooldownUntil) { + shouldThrottle = true; + nextAllowedTimestamp = tracker.cooldownUntil; + } else { + // Clean old timestamps + tracker.timestamps = tracker.timestamps.filter( + t => t > now - backpressure.windowMs + ); + + // Add current timestamp + tracker.timestamps.push(now); + + // Check if threshold exceeded + if (tracker.timestamps.length >= backpressure.maxErrors) { + shouldThrottle = true; + tracker.cooldownUntil = now + backpressure.cooldownMs; + nextAllowedTimestamp = tracker.cooldownUntil; + } + } + + errorTracker.set(category, tracker); + } + + return { + originalMessage: message, + category, + severity, + details: Object.keys(details).length > 0 ? details : undefined, + shouldThrottle, + nextAllowedTimestamp + }; + } + } + + return { + originalMessage: message, + category: 'UNKNOWN_ERROR', + severity: 'medium', + shouldThrottle: false + }; +} + +// Optional: Add method to clear error tracking +export function clearErrorTracking(): void { + errorTracker.clear(); +} \ No newline at end of file