crawlforge-mcp-server 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,602 +0,0 @@
1
- /**
2
- * AlertNotificationSystem - Enhanced notification system for change tracking
3
- * Supports email, webhook, and Slack notifications with throttling and aggregation
4
- */
5
-
6
- import { EventEmitter } from 'events';
7
- // Using native fetch (Node.js 18+)
8
- import crypto from 'crypto';
9
- import { identityHeaders } from '../utils/fetchIdentity.js';
10
-
11
- export class AlertNotificationSystem extends EventEmitter {
12
- constructor(options = {}) {
13
- super();
14
-
15
- this.options = {
16
- throttlingEnabled: true,
17
- aggregationEnabled: true,
18
- retryAttempts: 3,
19
- retryDelay: 5000,
20
- signatureSecret: process.env.WEBHOOK_SECRET || 'default-secret',
21
- ...options
22
- };
23
-
24
- // Notification queues and throttling
25
- this.notificationQueue = [];
26
- this.throttleCache = new Map();
27
- this.alertAggregation = new Map();
28
- this.retryQueue = new Map();
29
-
30
- // Statistics
31
- this.stats = {
32
- totalNotifications: 0,
33
- successfulNotifications: 0,
34
- failedNotifications: 0,
35
- throttledNotifications: 0,
36
- aggregatedNotifications: 0,
37
- webhooksSent: 0,
38
- emailsSent: 0,
39
- slackMessagesSent: 0
40
- };
41
-
42
- // Start processing queue
43
- this.startQueueProcessor();
44
- }
45
-
46
- /**
47
- * Send notification with throttling and aggregation
48
- * @param {Object} notification - Notification configuration
49
- */
50
- async sendNotification(notification) {
51
- try {
52
- const {
53
- type, // webhook, email, slack
54
- target, // URL, email address, etc.
55
- data,
56
- throttle = 0,
57
- aggregateKey = null,
58
- priority = 'medium'
59
- } = notification;
60
-
61
- // Check throttling
62
- if (this.options.throttlingEnabled && throttle > 0) {
63
- const throttleKey = `${type}_${target}_${aggregateKey || 'default'}`;
64
- const lastSent = this.throttleCache.get(throttleKey);
65
-
66
- if (lastSent && Date.now() - lastSent < throttle) {
67
- this.stats.throttledNotifications++;
68
- this.emit('notificationThrottled', { notification, throttleKey });
69
- return { success: false, reason: 'throttled' };
70
- }
71
- }
72
-
73
- // Check aggregation
74
- if (this.options.aggregationEnabled && aggregateKey) {
75
- const result = this.handleAggregation(notification, aggregateKey);
76
- if (result.aggregated) {
77
- this.stats.aggregatedNotifications++;
78
- this.emit('notificationAggregated', { notification, aggregateKey });
79
- return { success: true, reason: 'aggregated' };
80
- }
81
- }
82
-
83
- // Add to queue
84
- this.notificationQueue.push({
85
- ...notification,
86
- id: `notification_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
87
- timestamp: Date.now(),
88
- attempts: 0
89
- });
90
-
91
- this.stats.totalNotifications++;
92
- this.emit('notificationQueued', notification);
93
-
94
- return { success: true, reason: 'queued' };
95
-
96
- } catch (error) {
97
- this.emit('error', { operation: 'sendNotification', error: error.message });
98
- return { success: false, reason: 'error', error: error.message };
99
- }
100
- }
101
-
102
- /**
103
- * Send webhook notification
104
- * @param {Object} config - Webhook configuration
105
- * @param {Object} data - Notification data
106
- */
107
- async sendWebhookNotification(config, data) {
108
- try {
109
- const {
110
- url,
111
- method = 'POST',
112
- headers = {},
113
- signingSecret,
114
- includeContent = false
115
- } = config;
116
-
117
- // Prepare payload
118
- const payload = {
119
- event: 'change_alert',
120
- timestamp: Date.now(),
121
- data: includeContent ? data : this.sanitizeData(data)
122
- };
123
-
124
- const body = JSON.stringify(payload);
125
-
126
- // Generate signature if secret provided
127
- const requestHeaders = {
128
- 'Content-Type': 'application/json',
129
- ...identityHeaders({ role: 'alerts' }),
130
- ...headers
131
- };
132
-
133
- if (signingSecret) {
134
- const signature = this.generateSignature(body, signingSecret);
135
- requestHeaders['X-Signature'] = signature;
136
- }
137
-
138
- const response = await fetch(url, {
139
- method,
140
- headers: requestHeaders,
141
- body,
142
- timeout: 30000
143
- });
144
-
145
- if (!response.ok) {
146
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
147
- }
148
-
149
- this.stats.webhooksSent++;
150
- this.stats.successfulNotifications++;
151
-
152
- this.emit('webhookSent', {
153
- url,
154
- status: response.status,
155
- data: payload
156
- });
157
-
158
- return { success: true, status: response.status };
159
-
160
- } catch (error) {
161
- this.stats.failedNotifications++;
162
- this.emit('webhookError', {
163
- url: config.url,
164
- error: error.message
165
- });
166
- throw error;
167
- }
168
- }
169
-
170
- /**
171
- * Send email notification (placeholder for integration)
172
- * @param {Object} config - Email configuration
173
- * @param {Object} data - Notification data
174
- */
175
- async sendEmailNotification(config, data) {
176
- try {
177
- const {
178
- recipients,
179
- subject = 'Content Change Alert',
180
- includeDetails = true
181
- } = config;
182
-
183
- // Email integration would go here
184
- // For now, just emit event for external handling
185
- const emailData = {
186
- to: recipients,
187
- subject,
188
- body: this.generateEmailBody(data, includeDetails),
189
- timestamp: Date.now()
190
- };
191
-
192
- this.emit('emailRequested', emailData);
193
-
194
- this.stats.emailsSent++;
195
- this.stats.successfulNotifications++;
196
-
197
- return { success: true, message: 'Email queued for external handling' };
198
-
199
- } catch (error) {
200
- this.stats.failedNotifications++;
201
- this.emit('emailError', {
202
- recipients: config.recipients,
203
- error: error.message
204
- });
205
- throw error;
206
- }
207
- }
208
-
209
- /**
210
- * Send Slack notification
211
- * @param {Object} config - Slack configuration
212
- * @param {Object} data - Notification data
213
- */
214
- async sendSlackNotification(config, data) {
215
- try {
216
- const {
217
- webhookUrl,
218
- channel,
219
- username = 'Change Tracker',
220
- iconEmoji = ':warning:'
221
- } = config;
222
-
223
- const payload = {
224
- channel,
225
- username,
226
- icon_emoji: iconEmoji,
227
- text: this.generateSlackMessage(data),
228
- attachments: this.generateSlackAttachments(data)
229
- };
230
-
231
- const response = await fetch(webhookUrl, {
232
- method: 'POST',
233
- headers: {
234
- 'Content-Type': 'application/json'
235
- },
236
- body: JSON.stringify(payload),
237
- timeout: 30000
238
- });
239
-
240
- if (!response.ok) {
241
- const errorText = await response.text();
242
- throw new Error(`Slack API error: ${response.status} - ${errorText}`);
243
- }
244
-
245
- this.stats.slackMessagesSent++;
246
- this.stats.successfulNotifications++;
247
-
248
- this.emit('slackSent', {
249
- channel,
250
- message: payload.text
251
- });
252
-
253
- return { success: true };
254
-
255
- } catch (error) {
256
- this.stats.failedNotifications++;
257
- this.emit('slackError', {
258
- channel: config.channel,
259
- error: error.message
260
- });
261
- throw error;
262
- }
263
- }
264
-
265
- /**
266
- * Process notification queue
267
- */
268
- startQueueProcessor() {
269
- setInterval(async () => {
270
- if (this.notificationQueue.length === 0) return;
271
-
272
- const notification = this.notificationQueue.shift();
273
-
274
- try {
275
- await this.processNotification(notification);
276
- } catch (error) {
277
- await this.handleNotificationFailure(notification, error);
278
- }
279
-
280
- }, 1000); // Process every second
281
- }
282
-
283
- /**
284
- * Process individual notification
285
- * @param {Object} notification - Notification to process
286
- */
287
- async processNotification(notification) {
288
- const { type, config, data } = notification;
289
-
290
- switch (type) {
291
- case 'webhook':
292
- await this.sendWebhookNotification(config, data);
293
- break;
294
- case 'email':
295
- await this.sendEmailNotification(config, data);
296
- break;
297
- case 'slack':
298
- await this.sendSlackNotification(config, data);
299
- break;
300
- default:
301
- throw new Error(`Unknown notification type: ${type}`);
302
- }
303
-
304
- // Update throttle cache
305
- if (notification.throttle && notification.throttle > 0) {
306
- const throttleKey = `${type}_${config.url || config.recipients?.[0] || config.channel}_${notification.aggregateKey || 'default'}`;
307
- this.throttleCache.set(throttleKey, Date.now());
308
- }
309
-
310
- this.emit('notificationProcessed', notification);
311
- }
312
-
313
- /**
314
- * Handle notification failure with retry logic
315
- * @param {Object} notification - Failed notification
316
- * @param {Error} error - Error that occurred
317
- */
318
- async handleNotificationFailure(notification, error) {
319
- notification.attempts = (notification.attempts || 0) + 1;
320
-
321
- if (notification.attempts < this.options.retryAttempts) {
322
- // Add to retry queue with delay
323
- setTimeout(() => {
324
- this.notificationQueue.push(notification);
325
- }, this.options.retryDelay * notification.attempts);
326
-
327
- this.emit('notificationRetry', {
328
- notification,
329
- attempt: notification.attempts,
330
- error: error.message
331
- });
332
- } else {
333
- // Max retries exceeded
334
- this.stats.failedNotifications++;
335
-
336
- this.emit('notificationFailed', {
337
- notification,
338
- error: error.message,
339
- finalAttempt: true
340
- });
341
- }
342
- }
343
-
344
- /**
345
- * Handle notification aggregation
346
- * @param {Object} notification - Notification to aggregate
347
- * @param {string} aggregateKey - Aggregation key
348
- * @returns {Object} - Aggregation result
349
- */
350
- handleAggregation(notification, aggregateKey) {
351
- const now = Date.now();
352
- const aggregationWindow = 300000; // 5 minutes
353
-
354
- if (!this.alertAggregation.has(aggregateKey)) {
355
- this.alertAggregation.set(aggregateKey, {
356
- notifications: [],
357
- firstSeen: now,
358
- lastSeen: now
359
- });
360
- }
361
-
362
- const aggregate = this.alertAggregation.get(aggregateKey);
363
- aggregate.notifications.push(notification);
364
- aggregate.lastSeen = now;
365
-
366
- // Check if aggregation window expired
367
- if (now - aggregate.firstSeen > aggregationWindow) {
368
- // Send aggregated notification
369
- this.sendAggregatedNotification(aggregateKey, aggregate);
370
- this.alertAggregation.delete(aggregateKey);
371
- return { aggregated: false };
372
- }
373
-
374
- return { aggregated: true };
375
- }
376
-
377
- /**
378
- * Send aggregated notification
379
- * @param {string} aggregateKey - Aggregation key
380
- * @param {Object} aggregate - Aggregated data
381
- */
382
- async sendAggregatedNotification(aggregateKey, aggregate) {
383
- const { notifications } = aggregate;
384
- const firstNotification = notifications[0];
385
-
386
- // Create aggregated data
387
- const aggregatedData = {
388
- ...firstNotification.data,
389
- aggregatedCount: notifications.length,
390
- timeSpan: {
391
- start: aggregate.firstSeen,
392
- end: aggregate.lastSeen
393
- },
394
- summary: this.generateAggregatedSummary(notifications)
395
- };
396
-
397
- // Send using first notification's configuration
398
- const aggregatedNotification = {
399
- ...firstNotification,
400
- data: aggregatedData,
401
- aggregated: true
402
- };
403
-
404
- await this.processNotification(aggregatedNotification);
405
- }
406
-
407
- /**
408
- * Generate aggregated summary
409
- * @param {Array} notifications - Notifications to summarize
410
- * @returns {Object} - Summary data
411
- */
412
- generateAggregatedSummary(notifications) {
413
- const urls = new Set();
414
- const significanceLevels = {};
415
- const changeTypes = {};
416
-
417
- notifications.forEach(notification => {
418
- const { url, significance, changeType } = notification.data;
419
- urls.add(url);
420
- significanceLevels[significance] = (significanceLevels[significance] || 0) + 1;
421
- changeTypes[changeType] = (changeTypes[changeType] || 0) + 1;
422
- });
423
-
424
- return {
425
- uniqueUrls: urls.size,
426
- urls: Array.from(urls),
427
- significanceDistribution: significanceLevels,
428
- changeTypeDistribution: changeTypes,
429
- totalChanges: notifications.length
430
- };
431
- }
432
-
433
- /**
434
- * Generate signature for webhook security
435
- * @param {string} body - Request body
436
- * @param {string} secret - Signing secret
437
- * @returns {string} - Signature
438
- */
439
- generateSignature(body, secret) {
440
- return crypto
441
- .createHmac('sha256', secret)
442
- .update(body, 'utf8')
443
- .digest('hex');
444
- }
445
-
446
- /**
447
- * Sanitize data for external transmission
448
- * @param {Object} data - Data to sanitize
449
- * @returns {Object} - Sanitized data
450
- */
451
- sanitizeData(data) {
452
- return {
453
- url: data.url,
454
- significance: data.significance,
455
- changeType: data.changeType,
456
- timestamp: data.timestamp,
457
- summary: data.summary
458
- };
459
- }
460
-
461
- /**
462
- * Generate email body
463
- * @param {Object} data - Notification data
464
- * @param {boolean} includeDetails - Include detailed information
465
- * @returns {string} - Email body
466
- */
467
- generateEmailBody(data, includeDetails) {
468
- let body = `Content Change Alert\n\n`;
469
- body += `URL: ${data.url}\n`;
470
- body += `Significance: ${data.significance.toUpperCase()}\n`;
471
- body += `Change Type: ${data.changeType.replace('_', ' ')}\n`;
472
- body += `Time: ${new Date(data.timestamp).toISOString()}\n\n`;
473
-
474
- if (data.summary) {
475
- body += `Summary:\n${data.summary.changeDescription}\n\n`;
476
- }
477
-
478
- if (includeDetails && data.details) {
479
- body += `Details:\n`;
480
- body += `- Similarity: ${Math.round(data.details.similarity * 100)}%\n`;
481
- body += `- Changes: ${data.details.addedElements?.length || 0} added, `;
482
- body += `${data.details.removedElements?.length || 0} removed, `;
483
- body += `${data.details.modifiedElements?.length || 0} modified\n`;
484
- }
485
-
486
- body += `\nGenerated by CrawlForge Change Tracker`;
487
-
488
- return body;
489
- }
490
-
491
- /**
492
- * Generate Slack message
493
- * @param {Object} data - Notification data
494
- * @returns {string} - Slack message
495
- */
496
- generateSlackMessage(data) {
497
- const emoji = this.getSignificanceEmoji(data.significance);
498
- return `${emoji} Content change detected on ${data.url}`;
499
- }
500
-
501
- /**
502
- * Generate Slack attachments
503
- * @param {Object} data - Notification data
504
- * @returns {Array} - Slack attachments
505
- */
506
- generateSlackAttachments(data) {
507
- return [{
508
- color: this.getSignificanceColor(data.significance),
509
- fields: [
510
- {
511
- title: 'URL',
512
- value: data.url,
513
- short: false
514
- },
515
- {
516
- title: 'Significance',
517
- value: data.significance.toUpperCase(),
518
- short: true
519
- },
520
- {
521
- title: 'Change Type',
522
- value: data.changeType.replace('_', ' '),
523
- short: true
524
- },
525
- {
526
- title: 'Summary',
527
- value: data.summary?.changeDescription || 'Change detected',
528
- short: false
529
- }
530
- ],
531
- footer: 'CrawlForge Change Tracker',
532
- ts: Math.floor(data.timestamp / 1000)
533
- }];
534
- }
535
-
536
- /**
537
- * Get emoji for significance level
538
- * @param {string} significance - Significance level
539
- * @returns {string} - Emoji
540
- */
541
- getSignificanceEmoji(significance) {
542
- const emojis = {
543
- 'none': ':white_circle:',
544
- 'minor': ':yellow_circle:',
545
- 'moderate': ':orange_circle:',
546
- 'major': ':red_circle:',
547
- 'critical': ':rotating_light:'
548
- };
549
- return emojis[significance] || ':grey_question:';
550
- }
551
-
552
- /**
553
- * Get color for significance level
554
- * @param {string} significance - Significance level
555
- * @returns {string} - Color code
556
- */
557
- getSignificanceColor(significance) {
558
- const colors = {
559
- 'none': '#36a64f',
560
- 'minor': '#ffeb3b',
561
- 'moderate': '#ff9800',
562
- 'major': '#f44336',
563
- 'critical': '#9c27b0'
564
- };
565
- return colors[significance] || '#9e9e9e';
566
- }
567
-
568
- /**
569
- * Get notification statistics
570
- * @returns {Object} - Statistics
571
- */
572
- getStats() {
573
- return {
574
- ...this.stats,
575
- queueLength: this.notificationQueue.length,
576
- throttleCacheSize: this.throttleCache.size,
577
- aggregationCacheSize: this.alertAggregation.size,
578
- successRate: this.stats.totalNotifications > 0 ?
579
- (this.stats.successfulNotifications / this.stats.totalNotifications) * 100 : 0
580
- };
581
- }
582
-
583
- /**
584
- * Clear all caches and queues
585
- */
586
- clear() {
587
- this.notificationQueue.length = 0;
588
- this.throttleCache.clear();
589
- this.alertAggregation.clear();
590
- this.retryQueue.clear();
591
- }
592
-
593
- /**
594
- * Cleanup resources
595
- */
596
- cleanup() {
597
- this.clear();
598
- this.removeAllListeners();
599
- }
600
- }
601
-
602
- export default AlertNotificationSystem;