Crucix — agent with dashboard, delta engine, Telegram/Discord bots
This commit is contained in:
549
lib/alerts/discord.mjs
Normal file
549
lib/alerts/discord.mjs
Normal file
@@ -0,0 +1,549 @@
|
||||
// Discord Alerter — Multi-tier alerts + slash commands via discord.js
|
||||
// Mirrors TelegramAlerter architecture: same eval logic, same tier system, same dedup
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
// ─── Alert Tiers (shared with Telegram) ─────────────────────────────────────
|
||||
|
||||
const TIER_CONFIG = {
|
||||
FLASH: { color: 0xFF0000, label: 'FLASH', cooldownMs: 5 * 60 * 1000, maxPerHour: 6 },
|
||||
PRIORITY: { color: 0xFFAA00, label: 'PRIORITY', cooldownMs: 30 * 60 * 1000, maxPerHour: 4 },
|
||||
ROUTINE: { color: 0x3498DB, label: 'ROUTINE', cooldownMs: 60 * 60 * 1000, maxPerHour: 2 },
|
||||
};
|
||||
|
||||
// Slash command definitions for Discord's API
|
||||
const SLASH_COMMANDS = [
|
||||
{ name: 'status', description: 'System health, last sweep time, source status' },
|
||||
{ name: 'sweep', description: 'Trigger a manual sweep cycle' },
|
||||
{ name: 'brief', description: 'Compact intelligence summary' },
|
||||
{ name: 'portfolio', description: 'Portfolio status (if Alpaca connected)' },
|
||||
{ name: 'alerts', description: 'Recent alert history' },
|
||||
{ name: 'mute', description: 'Mute alerts (default 1h)',
|
||||
options: [{ name: 'hours', description: 'Hours to mute (default: 1)', type: 10, required: false }] },
|
||||
{ name: 'unmute', description: 'Resume alerts' },
|
||||
];
|
||||
|
||||
export class DiscordAlerter {
|
||||
constructor({ botToken, channelId, guildId, webhookUrl }) {
|
||||
this.botToken = botToken;
|
||||
this.channelId = channelId;
|
||||
this.guildId = guildId; // Server ID for slash command registration
|
||||
this.webhookUrl = webhookUrl; // Fallback: webhook-only mode (no bot needed)
|
||||
this._client = null;
|
||||
this._alertHistory = [];
|
||||
this._contentHashes = {};
|
||||
this._muteUntil = null;
|
||||
this._commandHandlers = {};
|
||||
this._ready = false;
|
||||
}
|
||||
|
||||
get isConfigured() {
|
||||
return !!(this.botToken && this.channelId) || !!this.webhookUrl;
|
||||
}
|
||||
|
||||
// ─── Bot Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start the Discord bot. Connects to the gateway, registers slash commands,
|
||||
* and begins listening for interactions.
|
||||
*/
|
||||
async start() {
|
||||
if (!this.isConfigured) return;
|
||||
|
||||
try {
|
||||
// Dynamic import — discord.js is optional, only loaded if configured
|
||||
const { Client, GatewayIntentBits, REST, Routes, EmbedBuilder, SlashCommandBuilder } = await import('discord.js');
|
||||
this._EmbedBuilder = EmbedBuilder;
|
||||
|
||||
this._client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds],
|
||||
});
|
||||
|
||||
// Register slash commands
|
||||
await this._registerCommands(REST, Routes, SlashCommandBuilder);
|
||||
|
||||
// Handle slash command interactions
|
||||
this._client.on('interactionCreate', async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
await this._handleCommand(interaction);
|
||||
});
|
||||
|
||||
// Connect
|
||||
await this._client.login(this.botToken);
|
||||
|
||||
this._client.once('ready', () => {
|
||||
this._ready = true;
|
||||
console.log(`[Discord] Bot online as ${this._client.user.tag}`);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'MODULE_NOT_FOUND' || err.message?.includes('Cannot find')) {
|
||||
console.warn('[Discord] discord.js not installed. Run: npm install discord.js');
|
||||
console.warn('[Discord] Falling back to webhook-only mode (if DISCORD_WEBHOOK_URL is set).');
|
||||
} else {
|
||||
console.error('[Discord] Failed to start bot:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the bot gracefully.
|
||||
*/
|
||||
async stop() {
|
||||
if (this._client) {
|
||||
this._client.destroy();
|
||||
this._client = null;
|
||||
this._ready = false;
|
||||
console.log('[Discord] Bot disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slash Command Registration ─────────────────────────────────────────
|
||||
|
||||
async _registerCommands(REST, Routes, SlashCommandBuilder) {
|
||||
const rest = new REST({ version: '10' }).setToken(this.botToken);
|
||||
|
||||
const commands = SLASH_COMMANDS.map(cmd => {
|
||||
const builder = new SlashCommandBuilder()
|
||||
.setName(cmd.name)
|
||||
.setDescription(cmd.description);
|
||||
|
||||
if (cmd.options) {
|
||||
for (const opt of cmd.options) {
|
||||
if (opt.type === 10) { // NUMBER
|
||||
builder.addNumberOption(o =>
|
||||
o.setName(opt.name).setDescription(opt.description).setRequired(opt.required ?? false)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.toJSON();
|
||||
});
|
||||
|
||||
try {
|
||||
if (this.guildId) {
|
||||
// Guild commands (instant, for development)
|
||||
await rest.put(Routes.applicationGuildCommands(this._client?.user?.id || 'me', this.guildId), { body: commands });
|
||||
console.log(`[Discord] Registered ${commands.length} guild slash commands`);
|
||||
} else {
|
||||
// Global commands (can take up to 1h to propagate)
|
||||
const appId = this._client?.application?.id;
|
||||
if (appId) {
|
||||
await rest.put(Routes.applicationCommands(appId), { body: commands });
|
||||
console.log(`[Discord] Registered ${commands.length} global slash commands`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Discord] Failed to register slash commands:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Command Handling ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a command handler.
|
||||
* @param {string} name - command name (without /)
|
||||
* @param {Function} handler - async (args) => responseText
|
||||
*/
|
||||
onCommand(name, handler) {
|
||||
this._commandHandlers[name.toLowerCase()] = handler;
|
||||
}
|
||||
|
||||
async _handleCommand(interaction) {
|
||||
const name = interaction.commandName;
|
||||
|
||||
// Built-in commands
|
||||
if (name === 'mute') {
|
||||
const hours = interaction.options.getNumber('hours') || 1;
|
||||
this._muteUntil = Date.now() + hours * 60 * 60 * 1000;
|
||||
await interaction.reply({
|
||||
embeds: [this._embed('Alerts Muted', `Alerts silenced for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC.\nUse \`/unmute\` to resume.`, 0x95A5A6)],
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'unmute') {
|
||||
this._muteUntil = null;
|
||||
await interaction.reply({
|
||||
embeds: [this._embed('Alerts Resumed', 'You will receive the next signal evaluation.', 0x2ECC71)],
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'alerts') {
|
||||
const recent = this._alertHistory.slice(-10);
|
||||
if (recent.length === 0) {
|
||||
await interaction.reply({ content: 'No recent alerts.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const tierEmoji = { FLASH: '🔴', PRIORITY: '🟡', ROUTINE: '🔵' };
|
||||
const lines = recent.map(a =>
|
||||
`${tierEmoji[a.tier] || '⚪'} **${a.tier}** — ${new Date(a.timestamp).toLocaleTimeString()}`
|
||||
);
|
||||
await interaction.reply({
|
||||
embeds: [this._embed(`Recent Alerts (${recent.length})`, lines.join('\n'), 0x3498DB)],
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to registered handlers
|
||||
const handler = this._commandHandlers[name];
|
||||
if (handler) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
try {
|
||||
const args = interaction.options.getString('input') || '';
|
||||
const response = await handler(args);
|
||||
if (response) {
|
||||
// If response is long, send as embed; otherwise plain text
|
||||
if (response.length > 200) {
|
||||
await interaction.editReply({ embeds: [this._embed('Crucix', response, 0x00E5FF)] });
|
||||
} else {
|
||||
await interaction.editReply({ content: response });
|
||||
}
|
||||
} else {
|
||||
await interaction.editReply({ content: 'Done.' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Discord] Command /${name} error:`, err.message);
|
||||
await interaction.editReply({ content: `Command failed: ${err.message}` });
|
||||
}
|
||||
} else {
|
||||
await interaction.reply({ content: `Unknown command: /${name}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sending Messages ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a message to the configured channel.
|
||||
* Works with the bot client or falls back to webhook URL.
|
||||
*/
|
||||
async sendMessage(content, embeds = []) {
|
||||
if (!this.isConfigured) return false;
|
||||
|
||||
// Try bot client first
|
||||
if (this._ready && this._client) {
|
||||
try {
|
||||
const channel = await this._client.channels.fetch(this.channelId);
|
||||
if (channel) {
|
||||
await channel.send({ content: content || undefined, embeds });
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Discord] Send via bot failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: webhook URL
|
||||
if (this.webhookUrl) {
|
||||
return this._sendWebhook(this.webhookUrl, content, embeds);
|
||||
}
|
||||
|
||||
console.warn('[Discord] Cannot send — bot not ready and no webhook URL configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
async _sendWebhook(url, content, embeds) {
|
||||
try {
|
||||
const body = {};
|
||||
if (content) body.content = content;
|
||||
if (embeds?.length > 0) {
|
||||
body.embeds = embeds.map(e => e.toJSON ? e.toJSON() : e);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
console.error(`[Discord] Webhook failed (${res.status}): ${err.substring(0, 200)}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Discord] Webhook error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible alias
|
||||
async sendAlert(message) {
|
||||
return this.sendMessage(message);
|
||||
}
|
||||
|
||||
// ─── Multi-Tier Alert Evaluation ────────────────────────────────────────
|
||||
// Identical logic to TelegramAlerter — shared eval pipeline
|
||||
|
||||
async evaluateAndAlert(llmProvider, delta, memory) {
|
||||
if (!this.isConfigured) return false;
|
||||
if (!delta?.summary?.totalChanges) return false;
|
||||
if (this._isMuted()) {
|
||||
console.log('[Discord] Alerts muted until', new Date(this._muteUntil).toLocaleTimeString());
|
||||
return false;
|
||||
}
|
||||
|
||||
const allSignals = [
|
||||
...(delta.signals?.new || []),
|
||||
...(delta.signals?.escalated || []),
|
||||
];
|
||||
|
||||
const newSignals = allSignals.filter(s => {
|
||||
const key = this._signalKey(s);
|
||||
if (typeof memory.isSignalSuppressed === 'function') {
|
||||
if (memory.isSignalSuppressed(key)) return false;
|
||||
} else {
|
||||
const alerted = memory.getAlertedSignals();
|
||||
if (alerted[key]) return false;
|
||||
}
|
||||
if (this._isSemanticDuplicate(s)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newSignals.length === 0) return false;
|
||||
|
||||
// LLM evaluation with rule-based fallback (reuse from Telegram)
|
||||
let evaluation = null;
|
||||
|
||||
if (llmProvider?.isConfigured) {
|
||||
try {
|
||||
const { TelegramAlerter } = await import('./telegram.mjs');
|
||||
const tgInstance = new TelegramAlerter({ botToken: null, chatId: null });
|
||||
const systemPrompt = tgInstance._buildEvaluationPrompt();
|
||||
const userMessage = tgInstance._buildSignalContext(newSignals, delta);
|
||||
const result = await llmProvider.complete(systemPrompt, userMessage, { maxTokens: 800, timeout: 30000 });
|
||||
evaluation = parseJSON(result.text);
|
||||
} catch (err) {
|
||||
console.warn('[Discord] LLM evaluation failed, falling back to rules:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!evaluation || typeof evaluation.shouldAlert !== 'boolean') {
|
||||
evaluation = this._ruleBasedEvaluation(newSignals, delta);
|
||||
if (evaluation) evaluation._source = 'rules';
|
||||
}
|
||||
|
||||
if (!evaluation?.shouldAlert) {
|
||||
console.log('[Discord] No alert —', evaluation?.reason || 'no qualifying signals');
|
||||
return false;
|
||||
}
|
||||
|
||||
const tier = TIER_CONFIG[evaluation.tier] ? evaluation.tier : 'ROUTINE';
|
||||
if (!this._checkRateLimit(tier)) {
|
||||
console.log(`[Discord] Rate limited for tier ${tier}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build Discord embed
|
||||
const embed = this._buildAlertEmbed(evaluation, delta, tier);
|
||||
const sent = await this.sendMessage(null, [embed]);
|
||||
|
||||
if (sent) {
|
||||
for (const s of newSignals) {
|
||||
const key = this._signalKey(s);
|
||||
memory.markAsAlerted(key, new Date().toISOString());
|
||||
this._recordContentHash(s);
|
||||
}
|
||||
this._recordAlert(tier);
|
||||
console.log(`[Discord] ${tier} alert sent (${evaluation._source || 'llm'}): ${evaluation.headline}`);
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
// ─── Discord-Native Rich Embed Formatting ───────────────────────────────
|
||||
|
||||
_buildAlertEmbed(evaluation, delta, tier) {
|
||||
const tc = TIER_CONFIG[tier];
|
||||
const tierEmoji = { FLASH: '🔴', PRIORITY: '🟡', ROUTINE: '🔵' }[tier] || '⚪';
|
||||
const confidenceEmoji = { HIGH: '🟢', MEDIUM: '🟡', LOW: '⚪' }[evaluation.confidence] || '⚪';
|
||||
|
||||
const embed = this._embed(
|
||||
`${tierEmoji} CRUCIX ${tc.label}`,
|
||||
`**${evaluation.headline}**\n\n${evaluation.reason}`,
|
||||
tc.color
|
||||
);
|
||||
|
||||
// Add fields
|
||||
const fields = [
|
||||
{ name: 'Direction', value: delta.summary.direction.toUpperCase(), inline: true },
|
||||
{ name: 'Confidence', value: `${confidenceEmoji} ${evaluation.confidence || 'MEDIUM'}`, inline: true },
|
||||
];
|
||||
|
||||
if (evaluation.crossCorrelation) {
|
||||
fields.push({ name: 'Cross-Correlation', value: evaluation.crossCorrelation, inline: true });
|
||||
}
|
||||
|
||||
if (evaluation.actionable && evaluation.actionable !== 'Monitor') {
|
||||
fields.push({ name: '💡 Action', value: evaluation.actionable, inline: false });
|
||||
}
|
||||
|
||||
if (evaluation.signals?.length) {
|
||||
fields.push({ name: 'Signals', value: evaluation.signals.join(' · '), inline: false });
|
||||
}
|
||||
|
||||
// discord.js EmbedBuilder style
|
||||
if (embed.setFields) {
|
||||
embed.setFields(fields);
|
||||
embed.setFooter({ text: `Crucix Intelligence · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` });
|
||||
} else {
|
||||
// Raw embed object for webhook fallback
|
||||
embed.fields = fields;
|
||||
embed.footer = { text: `Crucix Intelligence · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` };
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple embed. Returns EmbedBuilder if available, otherwise raw object.
|
||||
*/
|
||||
_embed(title, description, color) {
|
||||
if (this._EmbedBuilder) {
|
||||
return new this._EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setColor(color)
|
||||
.setTimestamp();
|
||||
}
|
||||
// Raw embed for webhook mode (no discord.js loaded)
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
color,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Rule-Based Fallback (same logic as Telegram) ───────────────────────
|
||||
|
||||
_ruleBasedEvaluation(signals, delta) {
|
||||
const criticals = signals.filter(s => s.severity === 'critical');
|
||||
const highs = signals.filter(s => s.severity === 'high');
|
||||
const nukeSignal = signals.find(s => s.key === 'nuke_anomaly');
|
||||
const osintNew = signals.filter(s => s.key?.startsWith('tg_urgent'));
|
||||
const marketSignals = signals.filter(s => ['vix', 'hy_spread', 'wti', 'brent', '10y2y'].includes(s.key));
|
||||
const conflictSignals = signals.filter(s => ['conflict_events', 'conflict_fatalities', 'thermal_total'].includes(s.key));
|
||||
|
||||
if (nukeSignal) {
|
||||
return { shouldAlert: true, tier: 'FLASH', confidence: 'HIGH', headline: 'Nuclear Anomaly Detected',
|
||||
reason: 'Safecast radiation monitors have flagged an anomaly.', actionable: 'Check dashboard immediately.',
|
||||
signals: ['nuke_anomaly'], crossCorrelation: 'radiation monitors' };
|
||||
}
|
||||
|
||||
const hasCriticalMarket = criticals.some(s => marketSignals.includes(s));
|
||||
const hasCriticalConflict = criticals.some(s => conflictSignals.includes(s) || osintNew.includes(s));
|
||||
if (criticals.length >= 2 && hasCriticalMarket && hasCriticalConflict) {
|
||||
return { shouldAlert: true, tier: 'FLASH', confidence: 'HIGH',
|
||||
headline: `${criticals.length} Critical Cross-Domain Signals`,
|
||||
reason: `Critical signals across market and conflict domains.`,
|
||||
actionable: 'Review dashboard. Assess exposure.',
|
||||
signals: criticals.map(s => s.label || s.key).slice(0, 5), crossCorrelation: 'market + conflict' };
|
||||
}
|
||||
|
||||
const escalatedHighs = [...criticals, ...highs].filter(s => s.direction === 'up');
|
||||
if (escalatedHighs.length >= 2) {
|
||||
return { shouldAlert: true, tier: 'PRIORITY', confidence: 'MEDIUM',
|
||||
headline: `${escalatedHighs.length} Escalating Signals`,
|
||||
reason: `Multiple indicators escalating: ${escalatedHighs.map(s => s.label || s.key).slice(0, 3).join(', ')}.`,
|
||||
actionable: 'Monitor for continuation.',
|
||||
signals: escalatedHighs.map(s => s.label || s.key).slice(0, 5), crossCorrelation: 'multi-indicator' };
|
||||
}
|
||||
|
||||
if (osintNew.length >= 5) {
|
||||
return { shouldAlert: true, tier: 'PRIORITY', confidence: 'MEDIUM',
|
||||
headline: `OSINT Surge: ${osintNew.length} New Urgent Posts`,
|
||||
reason: `${osintNew.length} new urgent OSINT signals. Elevated conflict tempo.`,
|
||||
actionable: 'Review OSINT stream.',
|
||||
signals: osintNew.map(s => (s.text || '').substring(0, 40)).slice(0, 3), crossCorrelation: 'telegram OSINT' };
|
||||
}
|
||||
|
||||
if (criticals.length >= 1 || highs.length >= 3) {
|
||||
const top = criticals[0] || highs[0];
|
||||
return { shouldAlert: true, tier: 'ROUTINE', confidence: 'LOW',
|
||||
headline: top.label || top.reason || 'Signal Change Detected',
|
||||
reason: `${criticals.length} critical, ${highs.length} high-severity signals.`,
|
||||
actionable: 'Monitor', signals: [...criticals, ...highs].map(s => s.label || s.key).slice(0, 4),
|
||||
crossCorrelation: 'single-domain' };
|
||||
}
|
||||
|
||||
return { shouldAlert: false, reason: `${signals.length} signals below alert threshold.` };
|
||||
}
|
||||
|
||||
// ─── Semantic Dedup (same as Telegram) ──────────────────────────────────
|
||||
|
||||
_contentHash(signal) {
|
||||
let content = '';
|
||||
if (signal.text) {
|
||||
content = signal.text.toLowerCase().replace(/\d{1,2}:\d{2}/g, '').replace(/\d+\.\d+%?/g, 'NUM').replace(/\s+/g, ' ').trim().substring(0, 120);
|
||||
} else if (signal.label) {
|
||||
content = `${signal.label}:${signal.direction || 'none'}`;
|
||||
} else {
|
||||
content = signal.key || JSON.stringify(signal).substring(0, 80);
|
||||
}
|
||||
return createHash('sha256').update(content).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
_isSemanticDuplicate(signal) {
|
||||
const hash = this._contentHash(signal);
|
||||
const lastSeen = this._contentHashes[hash];
|
||||
if (!lastSeen) return false;
|
||||
return new Date(lastSeen).getTime() > (Date.now() - 4 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
_recordContentHash(signal) {
|
||||
const hash = this._contentHash(signal);
|
||||
this._contentHashes[hash] = new Date().toISOString();
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
for (const [h, ts] of Object.entries(this._contentHashes)) {
|
||||
if (new Date(ts).getTime() < cutoff) delete this._contentHashes[h];
|
||||
}
|
||||
}
|
||||
|
||||
_signalKey(signal) {
|
||||
if (signal.text) return `dc:${this._contentHash(signal)}`;
|
||||
return signal.key || signal.label || JSON.stringify(signal).substring(0, 60);
|
||||
}
|
||||
|
||||
// ─── Rate Limiting ──────────────────────────────────────────────────────
|
||||
|
||||
_checkRateLimit(tier) {
|
||||
const config = TIER_CONFIG[tier];
|
||||
if (!config) return true;
|
||||
const now = Date.now();
|
||||
const lastSame = this._alertHistory.filter(a => a.tier === tier).pop();
|
||||
if (lastSame && (now - lastSame.timestamp) < config.cooldownMs) return false;
|
||||
const recentCount = this._alertHistory.filter(a => a.tier === tier && a.timestamp > now - 3600000).length;
|
||||
return recentCount < config.maxPerHour;
|
||||
}
|
||||
|
||||
_recordAlert(tier) {
|
||||
this._alertHistory.push({ tier, timestamp: Date.now() });
|
||||
if (this._alertHistory.length > 50) this._alertHistory = this._alertHistory.slice(-50);
|
||||
}
|
||||
|
||||
_isMuted() {
|
||||
if (!this._muteUntil) return false;
|
||||
if (Date.now() > this._muteUntil) { this._muteUntil = null; return false; }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function parseJSON(text) {
|
||||
if (!text) return null;
|
||||
let cleaned = text.trim();
|
||||
if (cleaned.startsWith('```')) cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
||||
try { return JSON.parse(cleaned); } catch {
|
||||
const match = cleaned.match(/\{[\s\S]*\}/);
|
||||
if (match) { try { return JSON.parse(match[0]); } catch { } }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user